import re
from enum import StrEnum
from functools import cache
from typing import Self, Union, Sequence, Any, overload, Optional, ClassVar, Literal

import attrs
import numpy as np
import pandas as pd
# noinspection PyProtectedMember
from pandas._typing import TakeIndexer, ArrayLike, PositionalIndexer, ScalarIndexer, SequenceIndexer, Dtype, type_t, Scalar
from pandas.core.accessor import register_dataframe_accessor, register_series_accessor
from pandas.core.arrays import ExtensionArray
from pandas.core.arrays.base import ExtensionArrayT, ExtensionArraySupportsAnyAll
from pandas.core.dtypes.base import register_extension_dtype, ExtensionDtype

from .config_dataclasses import NG911Field, NG911FeatureClass
from .misc import Parity, MixedZeroParityError, calculate_parity, FeatureAttributeErrorCode, strjoin
from .session import config


class InconsistentParityError(Exception):
    pass


class InvertedAddressRangeError(Exception):
    def __init__(self, from_addr: int, to_addr: int):
        if from_addr <= to_addr:
            raise ValueError(f"Attempted to instantiate InvertedAddressRangeError with increasing range.")
        else:
            super().__init__(f"Address range from {from_addr} to {to_addr} is inverted.")


class Directionality(StrEnum):
    """Represents the directionality of an address range."""

    INCREASING = "Increasing"
    """Represents an address range where the to-address is higher than the from-address."""

    DECREASING = "Decreasing"
    """Represents an address range where the to-address is lower than the from-address."""

    SINGLE = "Single"
    """Represents an address range where the from-address and to-address are equal and non-zero."""

    ZERO = "Zero"
    """Represents an address range where the parity is :attr:`Parity.ZERO`."""


@attrs.frozen
class AddressRange(object):
    from_addr: int = attrs.field(validator=attrs.validators.ge(0))
    to_addr: int = attrs.field(validator=attrs.validators.ge(0))
    parity: Parity = attrs.field(validator=attrs.validators.instance_of(Parity))
    suppress_errors: bool = attrs.field(default=False)

    def __attrs_post_init__(self):
        if self.suppress_errors:
            return
        else:
            self._validate()

    @cache
    def _validate(self) -> bool:
        if self.parity == Parity.ZERO and not (self.from_addr == self.to_addr == 0):
            raise MixedZeroParityError(f"Parity was set to ZERO, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity != Parity.ZERO and 0 in (self.from_addr, self.to_addr):
            raise MixedZeroParityError(f"Parity was NOT set to ZERO, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity != Parity.BOTH and calculate_parity((self.from_addr, self.to_addr), True) == Parity.BOTH:
            raise InconsistentParityError(f"Parity was set to {self.parity} but should be BOTH; from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity in (Parity.ODD, Parity.EVEN) and self.parity != calculate_parity([self.from_addr, self.to_addr], True):
            raise InconsistentParityError(f"Parity was set to {self.parity}, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        # if self.from_addr > self.to_addr:
        #     raise InvertedAddressRangeError(self.from_addr, self.to_addr)
        return True

    @classmethod
    def auto_fix_directionality(cls, from_addr: int, to_addr: int, parity: Parity, suppress_errors: bool = False) -> Self:
        if from_addr > to_addr:
            return cls(to_addr, from_addr, parity, suppress_errors)
        else:
            return cls(from_addr, to_addr, parity, suppress_errors)

    @classmethod
    def from_set(cls, set_: set[int], parity: Parity):
        from_addr = min(set_)
        to_addr = max(set_)
        if parity in (Parity.ODD, Parity.EVEN):
            if set_ != set(range(from_addr, to_addr + 1, 2)):
                raise ValueError("The given set is inconsistent with the given parity and/or does not include all possible values.")
        return cls(from_addr, to_addr, parity)

    @classmethod
    def zero(cls) -> Self:
        return cls(0, 0, Parity.ZERO)

    @property
    @cache
    def is_valid(self) -> bool:
        try:
            return self._validate()
        except:
            return False

    @property
    @cache
    def directionality(self) -> Directionality:
        if self.from_addr == self.to_addr == 0:
            return Directionality.ZERO
        elif self.from_addr == self.to_addr:
            return Directionality.SINGLE
        elif self.to_addr > self.from_addr:
            return Directionality.INCREASING
        elif self.to_addr < self.from_addr:
            return Directionality.DECREASING
        else:
            raise RuntimeError(strjoin([
                "Could not compute directionality.",
                f"from_addr = {self.from_addr}",
                f"to_addr = {self.to_addr}",
                f"parity = {self.parity}",
                f"suppress_errors = {self.suppress_errors}",
                f"is_valid = {self.is_valid}"
            ], "\n\t"))

    @property
    @cache
    def directionality_is_valid(self) -> bool:
        """Returns ``True`` if the instance's :attr:`directionality` is
        :attr:`~Directionality.INCREASING`, :attr:`~Directionality.SINGLE`, or
        :attr:`~Directionality.ZERO`. Otherwise, returns ``False``."""
        return self.directionality in {
            Directionality.INCREASING,
            Directionality.SINGLE,
            Directionality.ZERO
        }

    def to_set(self) -> set[int]:
        """Returns a ``set`` containing all integers of appropriate parity
        between ``from_addr`` and ``to_addr``, inclusive. If parity is zero, an
        empty set is returned."""
        if self.parity == Parity.ZERO:
            return set()
        if self.parity in (Parity.ODD, Parity.EVEN):
            return set(range(self.from_addr, self.to_addr + 1, 2))
        else:
            return set(range(self.from_addr, self.to_addr + 1))

    def to_array(self) -> np.ndarray:
        if self.parity == Parity.ZERO:
            return np.ndarray((0, ), dtype=np.uint32)
        elif self.parity in (Parity.ODD, Parity.EVEN):
            return np.arange(self.from_addr, self.to_addr + 1, 2, dtype=np.uint32)
        else:
            return np.arange(self.from_addr, self.to_addr + 1, dtype=np.uint32)

    def overlaps(self, other: Self) -> bool:
        return bool(self & other)

    def __contains__(self, item: Union[int, Self]) -> bool:
        self._validate()
        if self.parity == Parity.ZERO:
            return False
        elif isinstance(item, int):
            if not self.from_addr <= item <= self.to_addr:
                return False
            elif self.parity in (Parity.ODD, Parity.EVEN):
                return calculate_parity(item) == self.parity
            else:
                return True
        elif isinstance(item, AddressRange):
            item._validate()
            if item.parity == Parity.ZERO:
                return False
            elif Parity.BOTH in (self.parity, item.parity) or self.parity == item.parity:
                return self.from_addr <= item.from_addr and self.to_addr >= item.to_addr
            else:
                return False
        else:
            raise TypeError(f"Expected int or AddressRange, got '{type(item)}'.")

    def __and__(self, other: Self) -> Self:
        self._validate()
        other._validate()
        if self == other:
            return __class__(self.from_addr, self.to_addr, self.parity)
        elif self.parity == Parity.ZERO or other.parity == Parity.ZERO:
            return self.zero()
        elif self.parity != other.parity and Parity.BOTH not in (self.parity, other.parity):
            # One is ODD, the other is EVEN, so no overlap
            return self.zero()
        elif self.from_addr > other.to_addr or self.to_addr < other.from_addr:
            return self.zero()

        lower_bound: int = other.from_addr if self.from_addr <= other.from_addr else self.from_addr
        upper_bound: int = other.to_addr if self.to_addr >= other.to_addr else self.to_addr

        if self.parity == other.parity == Parity.BOTH:
            # Both are BOTH
            return __class__(lower_bound, upper_bound, Parity.BOTH)
        else:
            lower_parity: Parity = calculate_parity(lower_bound)
            upper_parity: Parity = calculate_parity(upper_bound)

        if self.parity == Parity.BOTH:
            # other is ODD or EVEN
            lower_bound += 1 if lower_parity != other.parity else 0
            upper_bound -= 1 if upper_parity != other.parity else 0
            return __class__(lower_bound, upper_bound, other.parity)
        else:
            # self is ODD or EVEN, other is BOTH or same as self
            lower_bound += 1 if lower_parity != self.parity else 0
            upper_bound -= 1 if upper_parity != self.parity else 0
            return __class__(lower_bound, upper_bound, self.parity)

    @property
    def details(self) -> str:
        """Returns a more detailed informational string than ``__str__()``."""
        return f"{self.from_addr}-{self.to_addr} [Parity = {self.parity}]"

    # def __str__(self) -> str:
    #     return f"{self.from_addr}-{self.to_addr} [{str(self.parity)[0]}]"

    def __bool__(self) -> bool:
        return self.parity != Parity.ZERO


class NGUIDFormatError(ValueError):
    invalid_nguid: str
    problem: FeatureAttributeErrorCode
    _raw_details: str | None
    details: str

    def __init__(self, invalid_nguid: str, problem: FeatureAttributeErrorCode, details: Optional[str] = None, piece: Optional[Literal["LAYER", "LOCAL_ID", "AGENCY_ID"]] = None):
        self.invalid_nguid = invalid_nguid
        self.problem = problem
        self._raw_details = details
        self.piece = piece  # None indicates whole NGUID

        if self.piece:
            piece_text = {"LAYER": "Layer", "LOCAL_ID": "Local ID", "AGENCY_ID": "Agency ID"}[self.piece]
            self.details = f"'{piece_text}' part of NGUID '{invalid_nguid}' has an invalid format: {details}" if details else f"NGUID '{invalid_nguid}' has an invalid format."
        else:
            self.details = f"NGUID '{invalid_nguid}' has an invalid format: {details}" if details else f"NGUID '{invalid_nguid}' has an invalid format."

        super().__init__(f"[{self.problem}] {self.details}")

    @property
    def validation_message(self) -> str:
        """Returns a single-line string suitable for writing to a validation
        error table."""
        if self._raw_details:
            lines = self._raw_details.split("\n\t")
            return lines[0] if len(lines) == 1 else lines[0] + " | ".join(lines[1:])
        else:
            return self.details.replace("\n", " ")


@attrs.frozen
class NGUID:
    layer: str = attrs.field()
    local_id: str = attrs.field(converter=str)
    agency_id: str = attrs.field()

    LAYER_PATTERN: ClassVar[re.Pattern] = re.compile(r"[A-Za-z_]\w*")
    LOCALID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[\w\(\)\[\]\{\}-]+")
    AGENCYID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[A-Za-z\d][A-Za-z\d-]*(?:\.[A-Za-z\d][A-Za-z\d-]*)+")
    NGUID_PATTERN: ClassVar[re.Pattern] = re.compile(fr"{config.gdb_info.nguid_urn_prefix}:({LAYER_PATTERN.pattern}):({LOCALID_PATTERN.pattern}):({AGENCYID_PATTERN.pattern})")
    V2_LOCALID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[\x20-\x39\x3b-\x3f\x41-\x7e]+")
    V2_NGUID_PATTERN: ClassVar[re.Pattern] = re.compile(fr"^({LAYER_PATTERN.pattern})_({V2_LOCALID_PATTERN.pattern})@({AGENCYID_PATTERN.pattern})$")

    def __str__(self):
        return f"{config.gdb_info.nguid_urn_prefix}:{self.layer}:{self.local_id}:{self.agency_id}"

    @classmethod
    def from_string(cls, string: str) -> Self:
        match: re.Match | None = cls.NGUID_PATTERN.match(string)
        if not match:
            raise cls._diagnose_v3(string)
        layer, local_id, agency_id = match.groups()
        if layer not in config.required_feature_class_names + config.optional_feature_class_names:
            raise NGUIDFormatError(string, "ERROR:NGUID:LAYER", f"Invalid layer '{layer}'.")
        if agency_id not in config.domains.AGENCYID.entries.keys():
            raise NGUIDFormatError(string, "ERROR:NGUID:AGENCY", f"Agency ID '{agency_id}' is not in domain '{config.domains.AGENCYID.name}'.")
        return cls(layer, local_id, agency_id)

    @classmethod
    def _diagnose_v3(cls, string: str) -> NGUIDFormatError:
        """
        Method to attempt to diagnose the specific format violation of (what
        should be) a v3 NGUID.

        :param string: The v3 NGUID string in violation
        :type string: str
        :return: Exception to analyze and/or raise
        :rtype: NGUIDFormatError
        """

        details: list[str] = []
        detail_message: str | None = None

        if cls.V2_NGUID_PATTERN.match(string):
            details.append("NGUID appears to be a v2 NGUID. Old (v2) NGUIDs need to be converted.")

        if not string.startswith(config.gdb_info.nguid_urn_prefix):
            details.append(f"NGUID should start with '{config.gdb_info.nguid_urn_prefix}'. Does this NGUID need to be converted from v2?")

        if "@" in string:
            details.append("NGUID contains an at-sign ('@'). Does this NGUID need to be converted from v2?")

        fc_name_expression: str = "|".join(config.required_feature_class_names + config.optional_feature_class_names)
        fc_underscore_localid_pattern = re.compile(f"({fc_name_expression})([-_@.,/|])?({cls.LOCALID_PATTERN.pattern})")
        if match := fc_underscore_localid_pattern.search(string):
            layer, delimiter, local_id = match.groups()
            details.append(f"NGUID appears to have the character '{delimiter}' instead of a colon (':') between the layer name '{layer}' and local ID '{local_id}'.")

        if len(details) == 1:
            detail_message = details.pop()
        elif len(details) > 1:
            detail_message = "\n\t" + "\n\t".join(details)

        return NGUIDFormatError(string, "ERROR:NGUID:FORMAT", detail_message)

    @classmethod
    def from_v2_string(cls, string: str) -> Self:
        match: re.Match | None = cls.V2_NGUID_PATTERN.match(string)
        if not match:
            details = (
                "Multiple '@' characters in v2 NGUID." if string.count("@") > 1
                else "Cannot convert from v2 NGUID containing a colon (':')." if ":" in string
                else None
            )
            raise NGUIDFormatError(string, "ERROR:NGUID:V2_FORMAT", details)
        result: Self = cls(*match.groups())
        if exc := cls.validate_string(str(result)):
            raise exc
        else:
            return result

    @classmethod
    def validate_string(cls, string: str) -> NGUIDFormatError | None:
        """
        Validates the format of an NGUID string.

        :param str string: NGUID to validate
        :return: ``None`` if the NGUID is valid, otherwise, an instance of
            :class:`NGUIDFormatError`
        :rtype: NGUIDFormatError | None
        """
        try:
            cls.from_string(string)
        except NGUIDFormatError as ex:
            return ex
        else:
            return None

    @property
    def feature_class(self) -> NG911FeatureClass:
        return config.get_feature_class_by_name(self.layer)

    @property
    def nguid_field(self) -> NG911Field:
        return self.feature_class.unique_id


# @register_extension_dtype
# class NGUIDDtype(ExtensionDtype):
#     # TODO: Finish implementation of this dtype and of the array type
#     type = NGUID
#     name = "nguid"
#     na_value = pd.NA
#
#     @classmethod
#     def construct_array_type(cls) -> type_t[ExtensionArray]:
#         return NGUIDArray
#
#     @property
#     def _is_numeric(self) -> bool:
#         return False
#
#     @property
#     def _is_boolean(self) -> bool:
#         return False
#
#
# class NGUIDArray(ExtensionArray):
#     dtype = NGUIDDtype()
#
#     def __init__(self, scalars: Sequence[NGUID | str]): ...
#
#     @classmethod
#     def _from_sequence(cls, scalars, *, dtype: Dtype | None = None, copy: bool = False):
#         return cls(scalars)
#
#     @classmethod
#     def _from_factorized(cls, values, original):
#         return super()._from_factorized(values, original)
#
#     @classmethod
#     def _from_sequence_of_strings(cls, strings, *, dtype: Dtype | None = None, copy: bool = False):
#         return super()._from_sequence_of_strings(strings, dtype=dtype, copy=copy)
#
#     @overload
#     def __getitem__(self, item: ScalarIndexer) -> Any:
#         ...
#
#     @overload
#     def __getitem__(self: ExtensionArrayT, item: SequenceIndexer) -> ExtensionArrayT:
#         ...
#
#     def __getitem__(self: ExtensionArrayT, item: PositionalIndexer) -> ExtensionArrayT | Any:
#         return super().__getitem__(item)
#
#     def __len__(self) -> int:
#         return super().__len__()
#
#     def __eq__(self, other: Any) -> ArrayLike:
#         return super().__eq__(other)
#
#     @property
#     def nbytes(self) -> int:
#         return super().nbytes
#
#     def isna(self) -> np.ndarray | ExtensionArraySupportsAnyAll:
#         return super().isna()
#
#     def take(self: ExtensionArrayT, indices: TakeIndexer, *, allow_fill: bool = False, fill_value: Any = None) -> ExtensionArrayT:
#         return super().take(indices, allow_fill=allow_fill, fill_value=fill_value)
#
#     def copy(self: ExtensionArrayT) -> ExtensionArrayT:
#         return super().copy()
#
#     @classmethod
#     def _concat_same_type(cls: type[ExtensionArrayT], to_concat: Sequence[ExtensionArrayT]) -> ExtensionArrayT:
#         return super()._concat_same_type(to_concat)
#
#
# @register_extension_dtype
# class AddressRangeDtype(ExtensionDtype):
#     # TODO: Finish implementation of this dtype and of the array type
#     _metadata = ("from_address_dtype", "to_address_dtype", "parity_dtype")
#     type = AddressRange
#     name = "addrng"
#     na_value = pd.NA
#
#     def __init__(self, address_ranges: Sequence[AddressRange]):
#         ...
#
#     @classmethod
#     def construct_array_type(cls) -> type_t[ExtensionArray]:
#         return AddressRangeArray
#
#     @property
#     def _is_numeric(self) -> bool:
#         return False
#
#     @property
#     def _is_boolean(self) -> bool:
#         return False
# # Experimental
#
#
# class AddressRangeArray(ExtensionArray):
#     dtype = AddressRangeDtype()
#
#     def __init__(self):
#         ...
#
#     @classmethod
#     def _from_sequence(cls, scalars, *, dtype: Dtype | None = None, copy: bool = False):
#         return cls(scalars)
#
#     @classmethod
#     def _from_factorized(cls, values, original):
#         return super()._from_factorized(values, original)
#
#     @classmethod
#     def _from_sequence_of_strings(cls, strings, *, dtype: Dtype | None = None, copy: bool = False):
#         return super()._from_sequence_of_strings(strings, dtype=dtype, copy=copy)
#
#     @overload
#     def __getitem__(self, item: ScalarIndexer) -> Any:
#         ...
#
#     @overload
#     def __getitem__(self: ExtensionArrayT, item: SequenceIndexer) -> ExtensionArrayT:
#         ...
#
#     def __getitem__(self: ExtensionArrayT, item: PositionalIndexer) -> ExtensionArrayT | Any:
#         return super().__getitem__(item)
#
#     def __len__(self) -> int:
#         return super().__len__()
#
#     def __eq__(self, other: Any) -> ArrayLike:
#         return super().__eq__(other)
#
#     @property
#     def nbytes(self) -> int:
#         return super().nbytes
#
#     def isna(self) -> np.ndarray | ExtensionArraySupportsAnyAll:
#         return super().isna()
#
#     def take(self: ExtensionArrayT, indices: TakeIndexer, *, allow_fill: bool = False, fill_value: Any = None) -> ExtensionArrayT:
#         return super().take(indices, allow_fill=allow_fill, fill_value=fill_value)
#
#     def copy(self: ExtensionArrayT) -> ExtensionArrayT:
#         return super().copy()
#
#     @classmethod
#     def _concat_same_type(cls: type[ExtensionArrayT], to_concat: Sequence[ExtensionArrayT]) -> ExtensionArrayT:
#         return super()._concat_same_type(to_concat)


@register_dataframe_accessor("ng911")
class NG911Accessor(object):
    def __init__(self, pandas_obj: pd.DataFrame):
        self._obj = pandas_obj

    def __getitem__(self, item: Union[NG911Field, Sequence[NG911Field]]) -> Union[pd.Series, pd.DataFrame]:
        indexer: Union[str, list[str]]
        if isinstance(item, Sequence):
            indexer = [f.name for f in item]
        else:
            indexer = item.name
        return self._obj[indexer]

    def address_range_left(self, suppress_errors=False, auto_fix_directionality=False) -> pd.Series:
        add_from: str = config.fields.add_l_from.name
        add_to: str = config.fields.add_l_to.name
        parity: str = config.fields.parity_l.name
        if not {add_from, add_to, parity}.issubset(self._obj.columns):
            raise KeyError(f"Columns '{add_from}', '{add_to}', '{parity}' are required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(
            lambda row:
                pd.NA if row.isna().any()
                else AddressRange.auto_fix_directionality(
                    row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
                ) if auto_fix_directionality
                else AddressRange(
                    row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
                ),
            axis=1
        )
        # ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(lambda row: AddressRange(row[add_from], row[add_to], Parity(p) if (p := row[parity]) is not pd.NA and p in parities else None, suppress_errors=True), axis=1)
        ranges.name = "$address_range_left"
        return ranges

    def address_range_right(self, suppress_errors=False, auto_fix_directionality=False) -> pd.Series:
        add_from: str = config.fields.add_r_from.name
        add_to: str = config.fields.add_r_to.name
        parity: str = config.fields.parity_r.name
        if not {add_from, add_to, parity}.issubset(self._obj.columns):
            raise KeyError(f"Columns '{add_from}', '{add_to}', '{parity}' are required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(
            lambda row:
            pd.NA if row.isna().any()
            else AddressRange.auto_fix_directionality(
                row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
            ) if auto_fix_directionality
            else AddressRange(
                row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
            ),
            axis=1
        )
        # ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(lambda row: AddressRange(row[add_from], row[add_to], Parity(p) if (p := row[parity]) is not pd.NA and p in parities else None, suppress_errors=True), axis=1)
        ranges.name = "$address_range_right"
        return ranges

    @property
    def parity_left(self) -> pd.Series:
        field_name: str = config.fields.parity_l.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        result: pd.Series = self._obj[field_name].apply(lambda value: Parity(value) if value in parities else pd.NA)
        result.name = "$parity_left"
        return result

    @property
    def parity_right(self) -> pd.Series:
        field_name: str = config.fields.parity_r.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        result: pd.Series = self._obj[field_name].apply(lambda value: Parity(value) if value in parities else pd.NA)
        result.name = "$parity_right"
        return result

    @property
    def address_parity(self) -> pd.Series:
        field_name: str = config.fields.addnumber.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parity_values: pd.Series = self._obj[field_name] % 2  # 0 = Even, 1 = Odd
        result: pd.Series = parity_values.pipe(Parity.from_modulo_result_series)
        result.name = "$parity"
        return result

    @property
    def directionality_left(self) -> pd.Series:
        result: pd.Series = self._obj.ng911.address_range_left().apply(lambda ar: ar.directionality if isinstance(ar, AddressRange) else pd.NA)
        result.name = "$directionality_left"
        return result

    @property
    def directionality_right(self) -> pd.Series:
        result: pd.Series = self._obj.ng911.address_range_right().apply(lambda ar: ar.directionality if isinstance(ar, AddressRange) else pd.NA)
        result.name = "$directionality_right"
        return result


@register_series_accessor("ng911")
class NG911SeriesAccessor(object):
    def __init__(self, pandas_obj: pd.Series):
        self._obj = pandas_obj

    def __getitem__(self, item: NGUID | str) -> Scalar:
        if self._obj.index.name != item.nguid_field.name:
            raise ValueError(f"This method is only available for NG911 feature class data frames.")
        return self._obj[str(item)]

    @property
    def field(self) -> NG911Field:
        return config.get_field_by_name(self._obj.index.name)